package mapspinner;

import java.nio.FloatBuffer;

import mapspinner.persistence.TerrainPersistence;
import mapspinner.persistence.datatypes.CompressedTerrainData;
import mapspinner.persistence.datatypes.TerrainData;
import mapspinner.persistence.datatypes.TerrainSettings;
import mapspinner.persistence.disk.DiskPersistence;
import mapspinner.util.ArrayUtils;
import mapspinner.util.LODUtil;
import mapspinner.util.PerlinHeightMap;
import mapspinner.util.TerrainEditor;
import mapspinner.util.TerrainRenderPass;
import mapspinner.util.TerrainRenderPass4;

import com.jme.math.FastMath;
import com.jme.math.Vector3f;
import com.jme.renderer.Renderer;
import com.jme.renderer.pass.BasicPassManager;
import com.jme.renderer.pass.Pass;
import com.jme.scene.Node;

public class Terrain extends Node {
	private static final long serialVersionUID = 1L;
	private final TerrainPersistence persistence;
	private TerrainSettings settings;

	private PerlinHeightMap generator;
	private TerrainNode[][] terrainNodes;
	private int[][] terrainIndices;
	private final TerrainStreamer streamer;
	private final TerrainKey terrainOffset;
	private final Node terrainRoot = new Node("TERRAIN_ROOT");
	private TerrainRenderPass pass;

	private String directory;
	private Grass grassSpinner;

	public Terrain(final boolean preload, final Vector3f newLoadCenter, final BasicPassManager pManager) {
		this(preload, newLoadCenter, pManager, "data");
	}

	public Terrain(final boolean preload, final Vector3f newLoadCenter,
			final BasicPassManager pManager, final String dataDir) {
		this(preload, newLoadCenter, pManager, dataDir, null);
	}

	public Terrain(final boolean preload, final Vector3f newLoadCenter,
			final BasicPassManager pManager, final String dataDir, TerrainRenderPass pass) {
		this.pass = pass;
		directory = dataDir;
		persistence = new DiskPersistence();
		loadSettings();
		terrainOffset = new TerrainKey((short) (-settings.getArrayWidth() / 2),
				(short) (-settings.getArrayWidth() / 2));

		buildIndices();
		attachChild(terrainRoot);
		this.pass.add(terrainRoot);
		this.pass.setShadingOnSpatial(terrainRoot);
		pManager.add(this.pass);
		// grassSpinner = new GrassSpinner(newLoadCenter, pManager, this);
		// attachChild(grassSpinner);
		streamer = new TerrainStreamer(preload, this, newLoadCenter);
		new Thread(streamer).start();
		name = "TERRAIN";
	}

	/**
	 * Builds initial index values for all the terrain blocks, these are reused
	 * forever.
	 */
	public void buildIndices() {
		final int[] detailLevels = new int[settings.getArrayWidth()
				* settings.getArrayWidth()];
		for (int x = 0; x < settings.getArrayWidth(); x++)
			for (int z = 0; z < settings.getArrayWidth(); z++) {
				final int dx = x - settings.getArrayWidth() / 2;
				final int dy = z - settings.getArrayWidth() / 2;
				final int distance = (int) FastMath.sqrt(dx * dx + dy * dy);
				int detail = distance * settings.getDetailLevels()
						/ (settings.getArrayWidth() / 2);
				if (detail > settings.getDetailLevels())
					detail = settings.getDetailLevels();
				if (detail < 0)
					detail = 0;
				detailLevels[x + z * settings.getArrayWidth()] = detail;
			}

		for (int z = 0; z < settings.getArrayWidth(); z++)
			for (int x = 0; x < settings.getArrayWidth(); x++) {
				byte sides = 0;
				final int detailLevel = detailLevels[x + z
						* settings.getArrayWidth()];
				if (z != 0 && z != settings.getArrayWidth() - 1)
					if (x != 0 && x != settings.getArrayWidth() - 1) {
						int oz = z - 1, ox = x;
						if (detailLevels[ox + oz * settings.getArrayWidth()] > detailLevel)
							sides += LODUtil.TOP;

						oz = z + 1;
						if (detailLevels[ox + oz * settings.getArrayWidth()] > detailLevel)
							sides += LODUtil.BOT;

						oz = z;
						ox = x - 1;
						if (detailLevels[ox + oz * settings.getArrayWidth()] > detailLevel)
							sides += LODUtil.LEFT;

						ox = x + 1;
						if (detailLevels[ox + oz * settings.getArrayWidth()] > detailLevel)
							sides += LODUtil.RIGHT;
					}
				terrainIndices[x][z] = detailLevel * LODUtil.BUF_PER_LEVEL
						+ sides;
			}
	}

	/**
	 * builds the initial set of terrain nodes.
	 */
	public void buildTerrainNodes() {
		for (int x = 0; x < settings.getArrayWidth() / 2; x++) {
			new ArrayUtils.SpiralVisitor(settings.getArrayWidth(), x, settings
					.getArrayWidth()
					/ 2 - x - 1) {
				@Override
				public void visit(final int x, final int z) {
					loadTerrain(keyFromIndex(x, z), terrainNodes[x][z]);
				}
			};
			new ArrayUtils.SpiralVisitor(settings.getArrayWidth(), x - 1,
					settings.getArrayWidth() / 2 - 1 - x - 1) {
				@Override
				public void visit(final int x, final int z) {
					stitch(x, z);
				}
			};
		}
	}

	public void clearTerrains() {
		for (int x = 0; x < settings.getArrayWidth() / 2; x++)
			new ArrayUtils.SpiralVisitor(settings.getArrayWidth(), x, settings
					.getArrayWidth()
					/ 2 - x - 1) {
				@Override
				public void visit(final int x, final int z) {
					terrainNodes[x][z].setState(TerrainNode.BUILT);
				}
			};
	}

	/**
	 * Creates the editing object for terrain
	 * 
	 * @return an editor instance.
	 */
	public TerrainEditor createBlockEditor() {
		saveOpenOnQuit();
		return new TerrainEditor(this);
	}

	public void createTerrains() {
		for (int x = 0; x < settings.getArrayWidth(); x++)
			for (int z = 0; z < settings.getArrayWidth(); z++) {
				final TerrainNode terrainNode = new TerrainNode(settings, pass);
				terrainNodes[x][z] = terrainNode;
				terrainRoot.attachChild(terrainNode);
			}
	}

	@Override
	public void draw(final Renderer r) {
		for (int x = 0; x < settings.getArrayWidth(); x++)
			for (int z = 0; z < settings.getArrayWidth(); z++)
				if (terrainNodes[x][z].update(r))
					break;

		for (int x = 0; x < settings.getArrayWidth() / 2; x++)
			new ArrayUtils.SpiralVisitor(settings.getArrayWidth(), x, settings
					.getArrayWidth()
					/ 2 - x - 1) {
				@Override
				public void visit(final int x, final int z) {
					terrainNodes[x][z].setIndexBuffer(terrainIndices[x][z]);
					terrainNodes[x][z].onDraw(r);
				}
			};
		if (grassSpinner != null)
			grassSpinner.draw(r);
	}

	/**
	 * retrieve a node based on a positional key
	 * 
	 * @param key
	 *            the positional key
	 * @return
	 */
	public TerrainNode get(final TerrainKey key) {
		return terrainNodes[keyToX(key)][keyToZ(key)];
	}

	/**
	 * gets a height corresponding to a point in world space
	 * 
	 * @param pos
	 * @return
	 */
	public float getHeight(final Vector3f pos) {
		final float x = pos.x;
		final float z = pos.z;
		final TerrainKey key = new TerrainKey(worldToKey(x), worldToKey(z));
		return terrainNodes[keyToX(key)][keyToZ(key)].getHeight(x
				- keyToWorld(key.x), z - keyToWorld(key.z));
	}

	public TerrainSettings getSettings() {
		return settings;
	}

	/**
	 * calculates the terrain nodes key based on a local index
	 * 
	 * @param x
	 *            the x index
	 * @param z
	 *            the z index
	 * @return
	 */
	private TerrainKey keyFromIndex(final int x, final int z) {
		return new TerrainKey((short) (terrainOffset.x + x),
				(short) (terrainOffset.z + z));
	}

	/**
	 * Converts key coordinates to world coordinates.
	 * 
	 * @param input
	 *            the input number
	 * @return the output number
	 */
	public float keyToWorld(final short input) {
		return input * settings.getHorizontalScale()
				* (settings.getResolution() - 1);
	}

	/**
	 * finds the local x index from a given positional key
	 * 
	 * @param key
	 *            the positional key
	 * @return
	 */
	public int keyToX(final TerrainKey key) {
		return key.x - terrainOffset.x;
	}

	/**
	 * finds the local z index from a given positional key
	 * 
	 * @param key
	 *            the positional key
	 * @return
	 */
	public int keyToZ(final TerrainKey key) {
		return key.z - terrainOffset.z;
	}

	public void loadSettings() {
		try {
			settings = persistence.getSettings(directory);
		} catch (final Exception e) {
			e.printStackTrace();
		}
		if (settings == null)
			settings = new TerrainSettings();

		if (terrainNodes == null
				|| terrainNodes.length != settings.getArrayWidth())
			terrainNodes = new TerrainNode[settings.getArrayWidth()][settings
					.getArrayWidth()];
		if (terrainIndices == null
				|| terrainIndices.length != settings.getArrayWidth())
			terrainIndices = new int[settings.getArrayWidth()][settings
					.getArrayWidth()];

		if(pass == null) {
			pass = new TerrainRenderPass4(settings.getTextureRepeat());
		}

		if (generator == null
				|| generator.getSize() != settings.getResolution()) {
			generator = new PerlinHeightMap(settings.getResolution());
			detachChild(terrainRoot);
			terrainRoot.detachAllChildren();
			createTerrains();
			attachChild(terrainRoot);
		}
	}

	/**
	 * Loads data into a terrain block, either from persistence or generated if
	 * no persisted data is available.
	 * 
	 * @param key
	 *            the position to load the data for.
	 * @param terrain
	 *            the block to implant the data into.
	 */
	public void loadTerrain(final TerrainKey key, final TerrainNode terrain) {
		try {
			final TerrainData data = persistence.getTerrainData(key, directory);
			float[] heightMap;
			String[] textures;
			FloatBuffer colorBuf;
			if (data != null) {
				heightMap = data.getHeightMap();
				colorBuf = data.getColorMap();
				textures = data.getTextures();
			} else {
				generator.setOffsets(key.x, key.z);
				generator.load();
				heightMap = generator.getHeightMap();
				colorBuf = null;
				textures = null;
			}
			if (terrain.edited)
				save(terrain);
			terrain.setDirectory(directory);
			terrain.build(heightMap, colorBuf, textures, key, new Vector3f(
					keyToWorld(key.x), 0, keyToWorld(key.z)));
		} catch (final Exception e) {
			e.printStackTrace();
		}
	}

	/**
	 * Collects the nodes at greater x and z to this node
	 * 
	 * @param x
	 *            local x index
	 * @param z
	 *            local z index
	 * @return
	 */
	private TerrainNode[] nodesAround(final int x, final int z) {
		final TerrainNode[] terrains = new TerrainNode[4];

		final int i[][] = new int[][] { { x, z }, { x + 1, z }, { x, z + 1 },
				{ x + 1, z + 1 } };

		int loop = 0;
		for (final int[] j : i) {
			for (final int n : j)
				if (n >= settings.getArrayWidth() || n < 0)
					return null;
			terrains[loop] = terrainNodes[j[0]][j[1]];
			if (terrains[loop].getState() < TerrainNode.BUILT)
				return null;
			loop++;
		}
		return terrains;
	}

	/**
	 * Calls nodesAround, based on a terrainKey
	 * 
	 * @param key
	 *            the positional key for this node
	 * @return
	 */
	public TerrainNode[] nodesAround(final TerrainKey key) {
		return nodesAround(keyToX(key), keyToZ(key));
	}

	public void save(final TerrainNode terrain) {
		if (!terrain.edited)
			return;
		final TerrainData data = new CompressedTerrainData(terrain.getTerrainTexture()
				.getLayerCount());
		data.setHeightMap(terrain.getHeightMap());
		data.setColorMap(terrain.getMesh().getColorBuffer());
		data.setTextures(terrain.getTerrainTexture().getTextures());
		try {
			persistence.setTerrainData(terrain.getKey(), data, terrain
					.getDirectory());
		} catch (final Exception e) {
			e.printStackTrace();
		}
	}

	private void saveOpenOnQuit() {
		Runtime.getRuntime().addShutdownHook(new Thread() {
			@Override
			public void run() {
				for (final TerrainNode[] row : terrainNodes)
					for (final TerrainNode terrain : row)
						save(terrain);
				super.run();
			}
		});
	}

	/**
	 * Changes the active terrain directory to a new one, this process is thread
	 * safe, so it could be done asynchronously.
	 */
	public void setDirectory(final String directory) {
		clearTerrains();
		this.directory = directory;

		buildTerrainNodes();
	}

	public void shiftTerrain(final int xShift, final int zShift) {
		terrainOffset.x += xShift;
		terrainOffset.z += zShift;

		new ArrayUtils.Shifter(xShift, zShift, terrainNodes);
		updateTerrainNodes();
	}

	/**
	 * Collects blocks around a terrain node and calls its stitching method.
	 * 
	 * @param x
	 *            local x index
	 * @param z
	 *            local z index
	 */
	private void stitch(final int x, final int z) {
		final TerrainNode[] terrains = nodesAround(x, z);
		if (terrains != null)
			terrains[0].stitch(terrains);
	}

	public void updateGrass() {
		if (grassSpinner != null)
			grassSpinner.update();
	}

	public void updateTerrainNodes() {
		// TODO; when the shifter code works, this can get removed
		for (int x = 0; x < settings.getArrayWidth(); x++)
			for (int z = 0; z < settings.getArrayWidth(); z++) {
				final TerrainKey key = keyFromIndex(x, z);
				final TerrainNode node = terrainNodes[x][z];
				if (key.hashCode() != node.getKey().hashCode())
					loadTerrain(key, node);
			}
		for (int x = 0; x < settings.getArrayWidth(); x++)
			for (int z = 0; z < settings.getArrayWidth(); z++)
				stitch(x, z);
	}

	/**
	 * Converts world coordinates to key coordinates.
	 * 
	 * @param input
	 *            the input number
	 * @return the output number
	 */
	public short worldToKey(final float input) {
		return (short) FastMath.floor(input / settings.getHorizontalScale()
				/ (settings.getResolution() - 1));
	}

}
